/*!
 *
 * This program is free software; you can redistribute it and/or modify it under the
 * terms of the GNU General Public License, version 2 as published by the Free Software
 * Foundation.
 *
 * You should have received a copy of the GNU General Public License along with this
 * program; if not, you can obtain a copy at http://www.gnu.org/licenses/gpl-2.0.html
 * or from the Free Software Foundation, Inc.,
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY;
 * without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 * See the GNU General Public License for more details.
 *
 *
 * Copyright (c) 2002-2018 Hitachi Vantara. All rights reserved.
 *
 */

package org.pentaho.platform.repository2.unified.jcr;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.pentaho.platform.api.mt.ITenantedPrincipleNameResolver;
import org.pentaho.platform.engine.core.system.PentahoSessionHolder;
import org.pentaho.platform.repository.RepositoryFilenameUtils;
import org.pentaho.platform.repository2.unified.ServerRepositoryPaths;
import org.springframework.util.Assert;

import javax.jcr.Node;
import javax.jcr.NodeIterator;
import javax.jcr.RepositoryException;
import javax.jcr.Session;
import javax.jcr.lock.Lock;
import javax.jcr.lock.LockManager;
import javax.jcr.security.AccessControlManager;
import javax.jcr.security.Privilege;
import java.io.Serializable;
import java.util.Arrays;
import java.util.Calendar;
import java.util.Date;
import java.util.List;

/**
 * Default implementation of {@link ILockHelper}. If user {@code suzy} in tenant {@code acme} locks a file with
 * UUID {@code abc} then this implementation will store the lock token {@code xyz} as
 * {@code /pentaho/acme/home/suzy/.lockTokens/abc/xyz}. It is assumed that {@code /pentaho/acme/home/suzy} is never
 * versioned! Putting lock token storage beneath the user's home folder provides access control.
 * 
 * <p>
 * This implementation stores a lock owner, lock date, and lock message in the ownerInfo payload. See JCR 2.0
 * section 17.3. If implemented as custom properties, then a versioned node would require a checkout and checkin to
 * lock a file. There is one caveat: implementations of JCR are free to ignore the ownerInfo payload. In that case,
 * the implementation sets the value. If that happens, we simply return that value as the lock owner and date and
 * message are null.
 * </p>
 * 
 * @author mlowery
 */
public class DefaultLockHelper implements ILockHelper {

  // ~ Static fields/initializers
  // ======================================================================================

  private static final String FOLDER_NAME_LOCK_TOKENS = ".lockTokens"; //$NON-NLS-1$

  private static final char LOCK_OWNER_INFO_SEPARATOR = ':';

  private static final String LOCK_OWNER_INFO_SEPARATOR_REGEX = "\\" + LOCK_OWNER_INFO_SEPARATOR; //$NON-NLS-1$

  private static final List<Character> RESERVED_CHARS = Arrays.asList( new Character[] { LOCK_OWNER_INFO_SEPARATOR } );

  private static final Log logger = LogFactory.getLog( DefaultLockHelper.class );

  private static final int POSITION_LOCK_OWNER = 0;

  private static final int POSITION_LOCK_DATE = 1;

  private static final int POSITION_LOCK_MESSAGE = 2;

  ITenantedPrincipleNameResolver userNameUtils;

  // ~ Instance fields
  // =================================================================================================

  // ~ Constructors
  // ====================================================================================================

  public DefaultLockHelper( ITenantedPrincipleNameResolver userNameUtils ) {
    super();
    this.userNameUtils = userNameUtils;
  }

  // ~ Methods
  // =========================================================================================================

  /**
   * Stores a lock token associated with the session's user.
   */
  protected void addLockToken( final Session session, final PentahoJcrConstants pentahoJcrConstants, final Lock lock )
    throws RepositoryException {
    Node lockTokensNode = getOrCreateLockTokensNode( session, pentahoJcrConstants, lock );
    Node newLockTokenNode =
        lockTokensNode.addNode( lock.getNode().getIdentifier(), pentahoJcrConstants.getPHO_NT_LOCKTOKENSTORAGE() );
    newLockTokenNode.setProperty( pentahoJcrConstants.getPHO_LOCKEDNODEREF(), lock.getNode() );
    newLockTokenNode.setProperty( pentahoJcrConstants.getPHO_LOCKTOKEN(), lock.getLockToken() );
    session.save();
  }

  /**
   * Returns all lock tokens belonging to the session's user. Lock tokens can then be added to the session by
   * calling {@code Session.addLockToken(token)}.
   * 
   * <p>
   * Callers should call {#link {@link #canUnlock(Session, PentahoJcrConstants, Lock)} if the token is being
   * retrieved for the purpose of an unlock.
   * </p>
   */
  protected String getLockToken( final Session session, final PentahoJcrConstants pentahoJcrConstants, final Lock lock )
    throws RepositoryException {
    Node lockTokensNode = getOrCreateLockTokensNode( session, pentahoJcrConstants, lock );
    NodeIterator nodes = lockTokensNode.getNodes( lock.getNode().getIdentifier() );
    Assert.isTrue( nodes.hasNext() );
    return nodes.nextNode().getProperty( pentahoJcrConstants.getPHO_LOCKTOKEN() ).getString();
  }

  /**
   * Removes a lock token so that it can never be associated with anyone's session again. (To be called after the
   * file has been unlocked and therefore the token associated with the lock is unnecessary.)
   */
  public void removeLockToken( final Session session, final PentahoJcrConstants pentahoJcrConstants, final Lock lock )
    throws RepositoryException {
    Node lockTokensNode = getOrCreateLockTokensNode( session, pentahoJcrConstants, lock );
    NodeIterator nodes = lockTokensNode.getNodes( lock.getNode().getIdentifier() );
    if ( nodes.hasNext() ) {
      nodes.nextNode().remove();
    }
    session.save();
  }

  protected Node getOrCreateLockTokensNode( final Session session, final PentahoJcrConstants pentahoJcrConstants,
      final Lock lock ) throws RepositoryException {
    String absPath =
        ServerRepositoryPaths.getUserHomeFolderPath( userNameUtils.getTenant( getLockOwner( session,
            pentahoJcrConstants, lock ) ), userNameUtils.getPrincipleName( getLockOwner( session, pentahoJcrConstants,
            lock ) ) );
    Node userHomeFolderNode = (Node) session.getItem( absPath );
    if ( userHomeFolderNode.hasNode( FOLDER_NAME_LOCK_TOKENS ) ) {
      return userHomeFolderNode.getNode( FOLDER_NAME_LOCK_TOKENS );
    } else {
      Node lockTokensNode =
          userHomeFolderNode.addNode( FOLDER_NAME_LOCK_TOKENS, pentahoJcrConstants.getPHO_NT_INTERNALFOLDER() );
      session.save();
      return lockTokensNode;
    }
  }

  /**
   * {@inheritDoc}
   */
  public boolean canUnlock( final Session session, final PentahoJcrConstants pentahoJcrConstants, final Lock lock )
    throws RepositoryException {
    String absPath =
        ServerRepositoryPaths.getUserHomeFolderPath( userNameUtils.getTenant( getLockOwner( session,
            pentahoJcrConstants, lock ) ), userNameUtils.getPrincipleName( getLockOwner( session, pentahoJcrConstants,
            lock ) ) );
    AccessControlManager acMgr = session.getAccessControlManager();
    return acMgr.hasPrivileges( absPath, new Privilege[] {
      acMgr.privilegeFromName( "jcr:read" ), acMgr.privilegeFromName( "jcr:write" ), //$NON-NLS-1$ //$NON-NLS-2$
      acMgr.privilegeFromName( "jcr:lockManagement" ) } ); //$NON-NLS-1$
  }

  /**
   * {@inheritDoc}
   */
  public void addLockTokenToSessionIfNecessary( final Session session, final PentahoJcrConstants pentahoJcrConstants,
      final Serializable fileId ) throws RepositoryException {
    Node fileNode = session.getNodeByIdentifier( fileId.toString() );
    if ( fileNode.isLocked() ) {
      LockManager lockManager = session.getWorkspace().getLockManager();
      Lock lock = lockManager.getLock( fileNode.getPath() );
      String lockToken = getLockToken( session, pentahoJcrConstants, lock );
      lockManager.addLockToken( lockToken );
    }
  }

  /**
   * {@inheritDoc}
   */
  public void removeLockTokenFromSessionIfNecessary( final Session session,
      final PentahoJcrConstants pentahoJcrConstants, final Serializable fileId ) throws RepositoryException {
    Node fileNode = session.getNodeByIdentifier( fileId.toString() );
    if ( fileNode.isLocked() ) {
      LockManager lockManager = session.getWorkspace().getLockManager();
      Lock lock = lockManager.getLock( fileNode.getPath() );
      String lockToken = getLockToken( session, pentahoJcrConstants, lock );
      lockManager.removeLockToken( lockToken );
    }
  }

  /**
   * {@inheritDoc}
   */
  public void unlockFile( final Session session, final PentahoJcrConstants pentahoJcrConstants,
      final Serializable fileId ) throws RepositoryException {
    Node fileNode = session.getNodeByIdentifier( fileId.toString() );
    LockManager lockManager = session.getWorkspace().getLockManager();
    Lock lock = lockManager.getLock( fileNode.getPath() );
    String lockToken = getLockToken( session, pentahoJcrConstants, lock );
    lockManager.addLockToken( lockToken );
    // get the lock again so that it has a non-null lockToken
    lock = lockManager.getLock( fileNode.getPath() );
    // don't need lock token anymore
    removeLockToken( session, pentahoJcrConstants, lock );
    lockManager.unlock( fileNode.getPath() );
  }

  /**
   * {@inheritDoc}
   */
  public void lockFile( final Session session, final PentahoJcrConstants pentahoJcrConstants,
      final Serializable fileId, final String message ) throws RepositoryException {
    LockManager lockManager = session.getWorkspace().getLockManager();
    // locks are always deep in this impl
    final boolean isDeep = true;
    // locks are always open-scoped since a session is short-lived and all work occurs in a transaction
    // anyway; from spec, "if a lock is enabled and then disabled within the same transaction, its effect never
    // makes it to the persistent workspace and therefore it does nothing"
    final boolean isSessionScoped = false;
    final long timeoutHint = Long.MAX_VALUE;
    final String ownerInfo =
        makeOwnerInfo( JcrTenantUtils.getTenantedUser( PentahoSessionHolder.getSession().getName() ), Calendar
            .getInstance().getTime(), message );
    Node fileNode = session.getNodeByIdentifier( fileId.toString() );
    Assert.isTrue( fileNode.isNodeType( pentahoJcrConstants.getMIX_LOCKABLE() ) );
    Lock lock = lockManager.lock( fileNode.getPath(), isDeep, isSessionScoped, timeoutHint, ownerInfo );
    addLockToken( session, pentahoJcrConstants, lock );
  }

  private String makeOwnerInfo( final String lockOwner, final Date lockDate, final String lockMessage ) {
    return escape( lockOwner ) + LOCK_OWNER_INFO_SEPARATOR + lockDate.getTime() + LOCK_OWNER_INFO_SEPARATOR
        + escape( lockMessage );
  }

  @Override
  public Date getLockDate( final Session session, final PentahoJcrConstants pentahoJcrConstants, final Lock lock )
    throws RepositoryException {
    String[] tokens = tokenize( lock.getLockOwner() );
    if ( tokens != null ) {
      long date;
      try {
        date = Long.parseLong( tokens[POSITION_LOCK_DATE] );
        return new Date( date );
      } catch ( NumberFormatException e ) {
        logger.debug( "could not parse lock date; returning null", e ); //$NON-NLS-1$
      }
    }
    return null;
  }

  @Override
  public String getLockMessage( final Session session, final PentahoJcrConstants pentahoJcrConstants, final Lock lock )
    throws RepositoryException {
    String[] tokens = tokenize( lock.getLockOwner() );
    if ( tokens != null ) {
      return unescape( tokens[POSITION_LOCK_MESSAGE] );
    }
    return null;
  }

  @Override
  public String getLockOwner( final Session session, final PentahoJcrConstants pentahoJcrConstants, final Lock lock )
    throws RepositoryException {
    String[] tokens = tokenize( lock.getLockOwner() );
    if ( tokens != null ) {
      return unescape( tokens[POSITION_LOCK_OWNER] );
    }
    // return whatever the implementation stored in this property
    return lock.getLockOwner();
  }

  private String[] tokenize( final String ownerInfo ) {
    if ( ownerInfo != null ) {
      String[] tokens = ownerInfo.split( LOCK_OWNER_INFO_SEPARATOR_REGEX );
      if ( tokens.length == 3 ) {
        return tokens;
      }
    }
    return null;
  }

  private static String escape( final String in ) {
    if ( in == null || in.trim().equals( "" ) ) { //$NON-NLS-1$
      return ""; //$NON-NLS-1$
    }
    return RepositoryFilenameUtils.escape( in, RESERVED_CHARS );
  }

  private static String unescape( final String in ) {
    if ( in == null || in.trim().equals( "" ) ) { //$NON-NLS-1$
      return ""; //$NON-NLS-1$
    }
    return RepositoryFilenameUtils.unescape( in );
  }

  // public static void main(final String[] args) {
  // System.out.println("'" + escape(null) + "'");
  // System.out.println("'" + escape("") + "'");
  // System.out.println("'" + escape("hello") + "'");
  // System.out.println("'" + escape("hell:o") + "'");
  // System.out.println("'" + escape("hello:") + "'");
  // System.out.println("'" + escape(":hello") + "'");
  // System.out.println("'" + escape("hell::o") + "'");
  // System.out.println("'" + escape("hell\\::o") + "'");
  //
  // System.out.println("'" + unescape(null) + "'");
  // System.out.println("'" + unescape("") + "'");
  // System.out.println("'" + unescape("hello") + "'");
  // System.out.println("'" + unescape("hell\\:o") + "'");
  // System.out.println("'" + unescape("hello\\:") + "'");
  // System.out.println("'" + unescape("\\:hello") + "'");
  // System.out.println("'" + unescape("hell\\:\\:o") + "'");
  // System.out.println("'" + unescape("hell\\\\:\\:o") + "'");
  //
  // System.out.println(Arrays.toString("su%3Azy:1332272120111:lock within versioned folder"
  // .split(LOCK_OWNER_INFO_SEPARATOR_REGEX)));
  // }
}
